Skip to content

fix(compose): reconcile up -d by service config hash#63

Merged
us merged 2 commits into
us:mainfrom
jahirvidrio:fix/compose-up-idempotent
Jul 4, 2026
Merged

fix(compose): reconcile up -d by service config hash#63
us merged 2 commits into
us:mainfrom
jahirvidrio:fix/compose-up-idempotent

Conversation

@jahirvidrio

Copy link
Copy Markdown
Contributor

What problem does this solve?

mocker compose up -d is not idempotent: re-running on an already-running project errors Conflict … already in use, breaking the standard "edit compose → up -d" workflow. The fix matches Docker's parity contract — up -d is a no-op for unchanged services, --force-recreate recreates everything, --no-recreate never recreates — and addresses the same parsed-but-dead-flag anti-pattern the maintainer fixed in PR #27 for image ls --filter/--format/--no-trunc.

Closes #59.

What changed

  • The config-hash function enumerates its fields explicitly via a private HashCodingKeys enum, so the hash surface is reviewable in one place and a new field on ComposeService does not affect the hash until it is added to the enumeration — matching the safe-by-default contract Docker's normalization provides.

  • The reconcile decision lives in a pure static reconcileDecision(...) on ComposeOrchestrator: no live engine, no fake runtime; the decision matrix is the contract and lives next to the implementation. The orchestrator's up(...) body becomes a thin coordinator around the helper.

  • --force-recreate and --no-recreate are mutually exclusive at parse time via a custom mutating func validate() on each command. Stubs considered and rejected — FlagExclusivity.exclusive only applies to EnumerableFlag flags, not Bool flags, so the custom validate path is the Swift-idiomatic equivalent.

jahirvidrio and others added 2 commits July 4, 2026 14:05
Previously ObservedContainer.state always defaulted to .running because
up() never populated it from the real container state, so
reconcileDecision's .keep branch fired purely on config-hash equality.
A .keep action skipped the start loop and unconditionally emitted
.containerRunning, so a stopped-but-matching container was left
stopped while mocker reported it as Running.

- Populate ObservedContainer.state from the real container state in up().
- Split ReconcileAction.Kind with a new .start case: hash matches but
  the container isn't running, so start it via engine.start(_:) instead
  of recreating or skipping it. Applies under --no-recreate too, since
  that flag only blocks recreation, not starting.
- Only emit .containerRunning after the container is confirmed running
  (already running for .keep, or successfully started for .start).
- Add reconcileDecision tests covering the state dimension: stopped/
  exited/created/dead + matching hash -> .start, running + matching
  hash -> .keep, --no-recreate + stopped -> .start, and stopped +
  diverged hash -> .removeAndRecreate (not .start).
@us
us force-pushed the fix/compose-up-idempotent branch from ba832ba to 1a43abf Compare July 4, 2026 11:08
@us
us merged commit 382190a into us:main Jul 4, 2026
1 check passed
@jahirvidrio
jahirvidrio deleted the fix/compose-up-idempotent branch July 4, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compose up is not idempotent — re-running (or --force-recreate) errors "container name already in use" instead of recreating

2 participants